D:\a\csshw\csshw\xtask\src\inject_agent_token.rs
Line | Count | Source |
1 | | //! Paseo agent GitHub auth injection. |
2 | | //! |
3 | | //! A paseo-spawned agent would otherwise inherit the user's full `gh` |
4 | | //! login — including classic scopes like `repo` that allow deleting |
5 | | //! repositories or force-pushing to `main`. This module is the |
6 | | //! counterpart of that risk: on worktree creation, it writes a |
7 | | //! per-worktree `.claude/settings.local.json` whose `env` map carries |
8 | | //! a fine-grained PAT supplied by the contributor. Claude Code |
9 | | //! injects that `env` into the agent process, and `gh` honors |
10 | | //! `GH_TOKEN` over the keyring, so the agent ends up acting as the |
11 | | //! scoped PAT while the contributor's own `gh` session outside paseo |
12 | | //! is unaffected. |
13 | | //! |
14 | | //! The token source is `<source-checkout>/.paseo/gh-token` — a |
15 | | //! gitignored file the contributor creates once per clone. The |
16 | | //! source checkout path is taken from the `PASEO_SOURCE_CHECKOUT_PATH` |
17 | | //! environment variable paseo sets when running setup steps; if that |
18 | | //! variable is absent, the current directory is used instead, which |
19 | | //! covers manual `cargo xtask inject-agent-token` invocations from |
20 | | //! the repo root. |
21 | | //! |
22 | | //! If the token file is missing the subcommand is a silent no-op |
23 | | //! (with an informational log line). If it contains anything other |
24 | | //! than a fine-grained PAT — e.g. a classic `ghp_…` or OAuth `gho_…` |
25 | | //! token — the subcommand aborts, since those token types grant far |
26 | | //! more than the least-privilege goal allows. |
27 | | |
28 | | use std::path::{Path, PathBuf}; |
29 | | |
30 | | use anyhow::{bail, Context, Result}; |
31 | | |
32 | | /// Expected prefix for a fine-grained personal access token. Classic |
33 | | /// tokens (`ghp_`) and OAuth tokens (`gho_`) are rejected to preserve |
34 | | /// the least-privilege property — classic tokens cannot be restricted |
35 | | /// to specific repositories or to a subset of repository permissions. |
36 | | const FINE_GRAINED_PREFIX: &str = "github_pat_"; |
37 | | |
38 | | /// Relative path inside the source checkout where the contributor |
39 | | /// stores their fine-grained PAT. |
40 | | const TOKEN_FILE_REL_PATH: &str = ".paseo/gh-token"; |
41 | | |
42 | | /// Relative path inside the worktree where Claude Code reads local, |
43 | | /// uncommitted per-project settings. |
44 | | const SETTINGS_FILE_REL_PATH: &str = ".claude/settings.local.json"; |
45 | | |
46 | | /// All side-effecting operations performed by this subcommand. |
47 | | /// |
48 | | /// Implement with mocks in tests to achieve zero filesystem, |
49 | | /// environment, or process side-effects. |
50 | | pub trait InjectAgentTokenSystem { |
51 | | /// Look up an environment variable. |
52 | | /// |
53 | | /// # Arguments |
54 | | /// |
55 | | /// * `key` - Environment variable name. |
56 | | /// |
57 | | /// # Returns |
58 | | /// |
59 | | /// `Some(value)` when the variable is set and non-empty, |
60 | | /// `None` otherwise. |
61 | | fn env_var(&self, key: &str) -> Option<String>; |
62 | | |
63 | | /// Return the current working directory. |
64 | | /// |
65 | | /// # Errors |
66 | | /// |
67 | | /// Returns an error if the current directory cannot be |
68 | | /// determined. |
69 | | fn current_dir(&self) -> Result<PathBuf>; |
70 | | |
71 | | /// Read the token file at `path`. |
72 | | /// |
73 | | /// # Arguments |
74 | | /// |
75 | | /// * `path` - Absolute or worktree-relative path to the token |
76 | | /// file. |
77 | | /// |
78 | | /// # Returns |
79 | | /// |
80 | | /// `Ok(Some(contents))` when the file exists and is readable, |
81 | | /// `Ok(None)` when it does not exist (the subcommand treats |
82 | | /// this as a no-op). |
83 | | /// |
84 | | /// # Errors |
85 | | /// |
86 | | /// Returns an error for filesystem failures other than |
87 | | /// "not found" (for example, permission denied). |
88 | | fn read_token_file(&self, path: &Path) -> Result<Option<String>>; |
89 | | |
90 | | /// Write `contents` to the settings file at `path`, creating |
91 | | /// any missing parent directories. |
92 | | /// |
93 | | /// # Arguments |
94 | | /// |
95 | | /// * `path` - Target path for the settings file. |
96 | | /// * `contents` - Full file contents to write. |
97 | | /// |
98 | | /// # Errors |
99 | | /// |
100 | | /// Returns an error if directory creation or the write fails. |
101 | | fn write_settings(&self, path: &Path, contents: &str) -> Result<()>; |
102 | | |
103 | | /// Emit an informational or warning message to the user. |
104 | | /// |
105 | | /// # Arguments |
106 | | /// |
107 | | /// * `msg` - Message to display. |
108 | | fn log(&self, msg: &str); |
109 | | } |
110 | | |
111 | | /// Production implementation of [`InjectAgentTokenSystem`]. |
112 | | pub struct RealSystem; |
113 | | |
114 | | #[cfg_attr(coverage_nightly, coverage(off))] |
115 | | impl InjectAgentTokenSystem for RealSystem { |
116 | | fn env_var(&self, key: &str) -> Option<String> { |
117 | | std::env::var(key).ok().filter(|v| !v.is_empty()) |
118 | | } |
119 | | |
120 | | fn current_dir(&self) -> Result<PathBuf> { |
121 | | std::env::current_dir().context("failed to resolve current directory") |
122 | | } |
123 | | |
124 | | fn read_token_file(&self, path: &Path) -> Result<Option<String>> { |
125 | | match std::fs::read_to_string(path) { |
126 | | Ok(contents) => Ok(Some(contents)), |
127 | | Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), |
128 | | Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())), |
129 | | } |
130 | | } |
131 | | |
132 | | fn write_settings(&self, path: &Path, contents: &str) -> Result<()> { |
133 | | if let Some(parent) = path.parent() { |
134 | | std::fs::create_dir_all(parent) |
135 | | .with_context(|| format!("failed to create {}", parent.display()))?; |
136 | | } |
137 | | std::fs::write(path, contents) |
138 | | .with_context(|| format!("failed to write {}", path.display()))?; |
139 | | Ok(()) |
140 | | } |
141 | | |
142 | | fn log(&self, msg: &str) { |
143 | | println!("{msg}"); |
144 | | } |
145 | | } |
146 | | |
147 | | /// Build the JSON body written to `.claude/settings.local.json`. |
148 | | /// |
149 | | /// Caller-enforced invariant: `token` contains only bytes in |
150 | | /// `[A-Za-z0-9_]`. That alphabet has no characters that require JSON |
151 | | /// escaping, which is what lets this function skip a general-purpose |
152 | | /// JSON encoder without risking injection. The invariant is enforced |
153 | | /// by [`is_fine_grained_pat_alphabet`] inside [`inject_agent_token`]. |
154 | | /// |
155 | | /// # Arguments |
156 | | /// |
157 | | /// * `token` - Fine-grained PAT, already validated and trimmed. |
158 | | /// |
159 | | /// # Returns |
160 | | /// |
161 | | /// A pretty-printed JSON document terminated with a newline. |
162 | 2 | fn build_settings_body(token: &str) -> String { |
163 | 2 | format!( |
164 | | "{{\n \"env\": {{\n \"GH_TOKEN\": \"{token}\",\n \"GH_HOST\": \"github.com\"\n }}\n}}\n" |
165 | | ) |
166 | 2 | } |
167 | | |
168 | | /// Return `true` when every byte of `token` is in the fine-grained |
169 | | /// PAT alphabet `[A-Za-z0-9_]`. |
170 | | /// |
171 | | /// Enforcing this invariant is what lets [`build_settings_body`] |
172 | | /// embed the token directly into a JSON template without escaping — |
173 | | /// none of the characters in this alphabet need JSON escaping, so a |
174 | | /// token that passes this check cannot break out of its string |
175 | | /// literal nor inject additional keys. |
176 | | /// |
177 | | /// # Arguments |
178 | | /// |
179 | | /// * `token` - Trimmed token to validate. |
180 | | /// |
181 | | /// # Returns |
182 | | /// |
183 | | /// `true` when `token` is non-empty and contains only the allowed |
184 | | /// characters; `false` otherwise. |
185 | 3 | fn is_fine_grained_pat_alphabet(token: &str) -> bool { |
186 | 3 | !token.is_empty() |
187 | 3 | && token |
188 | 3 | .bytes() |
189 | 55 | .all3 (|b| b.is_ascii_alphanumeric() || b == b'_'7 ) |
190 | 3 | } |
191 | | |
192 | | /// Resolve the source checkout directory. |
193 | | /// |
194 | | /// Paseo passes `PASEO_SOURCE_CHECKOUT_PATH` into `worktree.setup` |
195 | | /// subprocesses. When the variable is missing — for example when the |
196 | | /// subcommand is invoked manually — fall back to the current |
197 | | /// directory so running it from the repo root behaves intuitively. |
198 | | /// |
199 | | /// # Arguments |
200 | | /// |
201 | | /// * `system` - Injected I/O provider. |
202 | | /// |
203 | | /// # Returns |
204 | | /// |
205 | | /// The source checkout path. |
206 | | /// |
207 | | /// # Errors |
208 | | /// |
209 | | /// Returns an error only when the fallback `current_dir` lookup |
210 | | /// fails. |
211 | 8 | fn resolve_source_checkout<S: InjectAgentTokenSystem>(system: &S) -> Result<PathBuf> { |
212 | 8 | if let Some(path7 ) = system.env_var("PASEO_SOURCE_CHECKOUT_PATH") { |
213 | 7 | return Ok(PathBuf::from(path)); |
214 | 1 | } |
215 | 1 | system.current_dir() |
216 | 8 | } |
217 | | |
218 | | /// Inject the contributor's fine-grained GitHub PAT into the |
219 | | /// current worktree's Claude Code settings. |
220 | | /// |
221 | | /// The token is read from `<source-checkout>/.paseo/gh-token`. A |
222 | | /// missing token file is treated as an opt-out: the function logs a |
223 | | /// notice and returns `Ok(())` so worktree creation is not blocked |
224 | | /// for contributors who have not set a token up yet. |
225 | | /// |
226 | | /// # Arguments |
227 | | /// |
228 | | /// * `system` - Injected I/O provider. |
229 | | /// |
230 | | /// # Returns |
231 | | /// |
232 | | /// `Ok(())` on success or when the token file is absent. |
233 | | /// |
234 | | /// # Errors |
235 | | /// |
236 | | /// Returns an error when a token file exists but does not start with |
237 | | /// [`FINE_GRAINED_PREFIX`], when its trimmed contents fall outside |
238 | | /// the fine-grained PAT alphabet (see [`is_fine_grained_pat_alphabet`]), |
239 | | /// or when the settings file cannot be written. |
240 | 8 | pub fn inject_agent_token<S: InjectAgentTokenSystem>(system: &S) -> Result<()> { |
241 | 8 | let source = resolve_source_checkout(system)?0 ; |
242 | 8 | let token_file = source.join(TOKEN_FILE_REL_PATH); |
243 | | |
244 | 8 | let Some(raw6 ) = system.read_token_file(&token_file)?0 else { |
245 | 2 | system.log(&format!( |
246 | 2 | "INFO - paseo agent GitHub auth: no {} found; agents will use your existing gh login. See CONTRIBUTING.md.", |
247 | 2 | token_file.display() |
248 | 2 | )); |
249 | 2 | return Ok(()); |
250 | | }; |
251 | | |
252 | 6 | let token = raw.trim(); |
253 | 6 | if token.is_empty() { |
254 | 1 | bail!( |
255 | | "{} is empty; expected a fine-grained PAT starting with `{}`. See CONTRIBUTING.md.", |
256 | 1 | token_file.display(), |
257 | | FINE_GRAINED_PREFIX |
258 | | ); |
259 | 5 | } |
260 | 5 | if !token.starts_with(FINE_GRAINED_PREFIX) { |
261 | 2 | bail!( |
262 | | "{} must contain a fine-grained PAT (prefix `{}`); classic `ghp_…` and OAuth `gho_…` tokens are not accepted because they cannot be scoped tightly enough. See CONTRIBUTING.md.", |
263 | 2 | token_file.display(), |
264 | | FINE_GRAINED_PREFIX |
265 | | ); |
266 | 3 | } |
267 | 3 | if !is_fine_grained_pat_alphabet(token) { |
268 | 1 | bail!( |
269 | | "{} contains characters outside the fine-grained PAT alphabet ([A-Za-z0-9_]); refusing to embed it in settings. See CONTRIBUTING.md.", |
270 | 1 | token_file.display() |
271 | | ); |
272 | 2 | } |
273 | | |
274 | 2 | let cwd = system.current_dir()?0 ; |
275 | 2 | let settings_path = cwd.join(SETTINGS_FILE_REL_PATH); |
276 | 2 | let body = build_settings_body(token); |
277 | 2 | system.write_settings(&settings_path, &body)?0 ; |
278 | | |
279 | 2 | system.log(&format!( |
280 | 2 | "INFO - paseo agent GitHub auth: wrote {} from {} (scoped PAT)", |
281 | 2 | settings_path.display(), |
282 | 2 | token_file.display() |
283 | 2 | )); |
284 | | |
285 | 2 | Ok(()) |
286 | 8 | } |
287 | | |
288 | | #[cfg(test)] |
289 | | #[path = "tests/test_inject_agent_token.rs"] |
290 | | mod tests; |